Skip to content

fix(turbo): stop the root-scoped test-input radius hashing node_modules, and gate the class - #17035

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-16555-test-input-radius-node-modules
Sep 9, 2026
Merged

fix(turbo): stop the root-scoped test-input radius hashing node_modules, and gate the class#17035
baozhoutao merged 1 commit into
mainfrom
claude/issue-16555-test-input-radius-node-modules

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Part of #16555

A $TURBO_ROOT$ glob is resolved against the filesystem, not against git's tracked set, so the
declared cross-package test-input radius (packages/**/*.json and its siblings) descends into every
installed dependency tree under packages/. vitest rewrites its own
node_modules/.vite/vitest/HASH/results.json on every run, so a task hashing such a glob can never
replay from cache on a runner that ran any vitest before it.

Two deliverables, and the second is the one that closes the class: fixing the globs alone leaves
the next root-scoped declaration free to re-commit the same defect with nothing going red.

1. The card's experiment, re-run — before leg first

Same worktree, nothing else touched between the two runs of each leg, turbo run test:repo --filter=@objectstack/spec. Wall-clock figures are shared-box seconds (taken under
scripts/pm/os-verify-lock.sh, which excludes other locked runs only).

leg turbo.json run 1 run 2
before merge base, 0 exclusion entries cache miss, hash 6da4b399a78da08a, 32.054s cache miss, hash 2bafab09a086a226, 30.784s
after this PR, 17 exclusion entries cache hit 6da4b399a78da08a, 197ms, FULL TURBO cache hit 6da4b399a78da08a, 184ms, FULL TURBO

The before leg reproduces the card exactly: the only thing that changed between run 1 and run 2 was
vitest writing its own results cache, and the task hash moved with it.

The after-leg hash is a stronger reading than "the second run hits": it is the same hash the
unfixed declaration produced before any vitest had run
(6da4b399a78da08a, the before leg's run 1).
So the exclusion subtracts vitest's own cache from the hash and nothing else.

2. The input map, with controls

turbo run test:repo --filter=@objectstack/spec --dry-run=json, on the tree after a real vitest run:

total input keys keys under a node_modules segment
before (merge-base turbo.json) 7,305 1
after (this PR) 7,304 0
  • The one removed key, verbatim: node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json — the file the card names, hash and all. Keys added: 0.
  • Firing control beside the zero: real tracked .json files under packages/** are still inputs — package.json and ../lint/package.json are both present, and 506 non-node_modules .json keys remain. The zero is a reading, not a glob that stopped matching.
  • Nonsense control: 0 keys match a path that does not exist.
  • The count differs from the card's 9,404 because that was a different tree with more vitest caches present; the class and the single moving key are identical.
  • Repo-wide safety control: 0 tracked files live under a node_modules segment (positive control: 5,471 tracked files live under a src segment), so the exclusion cannot subtract any tracked repo source from any task.

3. Measured: turbo does NOT honour a negated $TURBO_ROOT$ input

The card marked this "to be measured". It was measured, on turbo 2.10.10, against
@objectstack/spec#test:repo with three probe files planted under three packages' dependency trees:

exclusion spelling total keys node_modules keys
(baseline, none) 7307 3
!$TURBO_ROOT$/packages/**/node_modules/** 7307 3 — inert
!$TURBO_ROOT$/packages/*/node_modules/** 7307 3 — inert
!$TURBO_ROOT$/**/node_modules/** 7307 3 — inert
!packages/**/node_modules/** (bare, package-relative) 7307 3 — inert (wrong base)
!**/node_modules/** (package-relative) — this PR 7304 0

Control for the negation mechanism itself, so the three inert rows are a reading about the root
token rather than about negation: a package-relative "!LICENSE" on the same task removed exactly
that one key (7307 to 7306, LICENSE 1 to 0).

So the answer is no — a negation carrying the root token is dropped rather than applied, silently.
This PR uses the package-relative form. It needs no per-package depth: ** spans zero or more
segments, so one string covers the task's own tree and the ../PKG/... keys a root-scoped glob
contributes alike. The measurement is recorded where the constant is declared, with a "do not tidy
this into the $TURBO_ROOT$ form" warning, because that form reads as consistent and enforces nothing.

4. The gate rule — the half that closes the class

The card's own words: the registry gate "checks that turbo hashes the declared globs, not what
else the glob happens to match
". Layer B now asks the second question too.

  • A declared glob whose reach includes a node_modules path requires the exclusion on its owning
    turbo task. Reach is answered by the shared matcher (glob-match.mjs) against an assembled witness
    path, never by sniffing the glob's text for a wildcard — so a glob is judged by the same semantics
    Layers A and B judge it by.
  • A declared glob that names the directory is refused outright: the exclusion would cancel it,
    leaving a declaration that reads as a radius and hashes nothing.
  • A declaration of literal file paths reaches nothing and owes neither.

The rule is shown to CATCH something

Ablation A — remove the fix, keep the rule. From the committed tree, turbo.json reverted to the
merge-base bytes (blob compared, not just "the file changed"), then restored:

leg 1  HEAD (17 exclusion entries)  -> gate exit 0
leg 2  merge-base bytes (0 entries) -> gate exit 1, 17 tasks named
                                    -> --self-test exit 1, failing case:
                                       "every live task whose globs reach node_modules carries the exclusion"
leg 3  restored (git diff HEAD empty, blob == HEAD blob) -> gate exit 0

Ablation B — the actual future case: a NEW root-scoped declaration. A packages/**/*.json glob
appended to a package that today declares only literal paths (@objectstack/mcp), together with its
matching turbo input — i.e. everything the OLD gate asked for:

gate exit=1
  - turbo.json "@objectstack/mcp#test" hashes glob(s) that reach into node_modules/ and carries no
    Add "!**/node_modules/**" to this task's inputs. (not a negated $TURBO_ROOT$ input ...)

Both ablations verified the mutation reached the loaded module, not merely the disk (the injected
literal was counted before and after, and the table was re-imported and printed), and both restored
by blob comparison with git diff HEAD empty. The first attempt at ablation B was a no-op — an
inserted duplicate globs: key that the later one overrode, gate exit 0 — and is reported here
rather than silently retried.

Negative control (mandatory), on the live tree

A rule that rejected any root-scoped glob would satisfy the above and break the repo. It does not:

  • 28 declaring packages. 17 own a glob that reaches node_modules and now carry the exclusion;
    11 declare only literal file paths, owe nothing, and are untouched and green.
  • The self-test pins both directions by name, including three cases labelled NEGATIVE CONTROL, and a
    live-tree case that fails on a repo whose turbo.json has drifted back (it is the case that fired
    in ablation A).
  • Full gate: OK: 28 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob (6 of them on a split "test:repo" task).

Verification

  • node scripts/check-cross-package-test-inputs.mjs exit 0; --self-test All 152 self-test cases passed (was 134; the new battery adds 18 and is registered in SELF_TEST_BATTERIES with the roster floor raised 8 to 9).
  • Derived gate family, node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at the final commit: 33 commands, 33 run, 0 non-zero. Reconciled with --ran: "33 derived famil(ies) accounted for — 33 run, 0 NOT-MEASURED."
  • @objectstack/spec#test:repo executed green in both before-leg runs (exit 0, 32.054s / 30.784s).
  • Lint, narrowed and declared: 2 files linted (count read from the eslint result array), 0 errors, 0 warnings. Universe read from eslint's own config, not a guess — 6,390 lintable tracked files it would judge. Invariance: this repo runs one eslint.config.mjs which never enables type-aware linting (no parserOptions.project, no typed rules) for ANY file, so this diff cannot move a verdict on the 6,388 untouched files. Nonsense control: eslint refuses a path that does not exist.
  • pnpm check:nul-bytes exit 0 (8,420 files); an independent control-character sweep over the three changed files matched nothing.

Scope notes

  • Clause-② re-derived, not copied: no. Path limb — node scripts/pm/dispatch-gates.mjs --tier at this commit: "the surface hits none of the 3 declared glob(s)"; SUSPECT_TIER_GLOBS is packages/spec/src/**, untouched. Content limb — no schema key, closed-set member, published export or registry entry moves; what moves is what turbo hashes plus one repo gate rule.
  • skip-changeset, measured rather than assumed: 0 published packages name scripts or turbo.json in files[] (positive control: 70 published packages name dist). Nothing published moves.
  • File surface. The brief named scripts/cross-package-test-inputs.mjs + turbo.json + the registry gate's self-test. The rule itself cannot live in a self-test, so scripts/check-cross-package-test-inputs.mjs carries it (the gate whose self-test the brief names); no fourth file is touched. Declared here rather than done quietly.
  • No governed surface is touched (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md), so no maintainer-briefing section is owed.
  • spec's declared test-input radius omits packages/**/*.tsx while its tree-walking tests read .tsx — and the glob that would cover it is forbidden by a dispatch-gates self-test pin #16593 is not addressed here and remains open. It is the mirror of this card — the same declaration, narrower than the walk instead of wider. See the report to the PM for a reading on the derived-from shape; that ruling is the PM's.

🤖 Generated with Claude Code

https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU


Generated by Claude Code

A `$TURBO_ROOT$` glob resolves against the filesystem, not git's tracked
set, so `packages/**/*.json` and friends descend into every installed
dependency tree under `packages/`. vitest rewrites its own
`node_modules/.vite/vitest/<hash>/results.json` on every run, so the task
that hashes such a glob can never replay from cache on a runner that ran
any vitest before it.

Measured on turbo 2.10.10: a negation carrying the root token
(`!$TURBO_ROOT$/packages/**/node_modules/**`, and three sibling
spellings) is dropped rather than applied — 7307 input keys and 3
node_modules keys before and after, unchanged. A package-relative
negation is honoured: `!**/node_modules/**` takes the same task to 7304
keys and 0 node_modules keys, removing exactly the three planted probe
files and nothing else. Control for the negation mechanism itself: a
package-relative `!LICENSE` on the same task removed exactly that one key.

The second half is the rule, because fixing the globs alone leaves the
next root-scoped declaration free to re-commit this silently: Layer B now
asks what else a declared glob MATCHES, not only whether turbo hashes it.
A glob whose reach includes a `node_modules` path — answered by the shared
matcher against an assembled witness, never by sniffing the glob text —
requires the exclusion on its owning task; a glob that NAMES the directory
is refused outright. A declaration of literal file paths owes neither and
stays green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants